// SiYuan - Build Your Eternal Digital Garden
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package model

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/88250/gulu"
	"github.com/88250/lute"
	"github.com/88250/lute/parse"
	"github.com/siyuan-note/filelock"
	"github.com/siyuan-note/logging"
	"github.com/siyuan-note/siyuan/kernel/conf"
	"github.com/siyuan-note/siyuan/kernel/sql"
	"github.com/siyuan-note/siyuan/kernel/treenode"
	"github.com/siyuan-note/siyuan/kernel/util"
)

var historyTicker = time.NewTicker(time.Minute * 10)

func AutoGenerateDocHistory() {
	ChangeHistoryTick(Conf.Editor.GenerateHistoryInterval)
	for {
		<-historyTicker.C
		generateDocHistory()
	}
}

func generateDocHistory() {
	defer logging.Recover()

	if 1 > Conf.Editor.GenerateHistoryInterval {
		return
	}

	WaitForWritingFiles()
	for _, box := range Conf.GetOpenedBoxes() {
		box.generateDocHistory0()
	}

	historyDir := util.HistoryDir
	clearOutdatedHistoryDir(historyDir)

	// 以下部分是老版本的清理逻辑，暂时保留

	for _, box := range Conf.GetBoxes() {
		historyDir = filepath.Join(util.DataDir, box.ID, ".siyuan", "history")
		clearOutdatedHistoryDir(historyDir)
	}

	historyDir = filepath.Join(util.DataDir, "assets", ".siyuan", "history")
	clearOutdatedHistoryDir(historyDir)

	historyDir = filepath.Join(util.DataDir, ".siyuan", "history")
	clearOutdatedHistoryDir(historyDir)
}

func ChangeHistoryTick(minutes int) {
	if 0 >= minutes {
		minutes = 3600
	}
	historyTicker.Reset(time.Minute * time.Duration(minutes))
}

func ClearWorkspaceHistory() (err error) {
	historyDir := util.HistoryDir
	if gulu.File.IsDir(historyDir) {
		if err = os.RemoveAll(historyDir); nil != err {
			logging.LogErrorf("remove workspace history dir [%s] failed: %s", historyDir, err)
			return
		}
		logging.LogInfof("removed workspace history dir [%s]", historyDir)
	}

	sql.InitHistoryDatabase(true)

	// 以下部分是老版本的清理逻辑，暂时保留

	notebooks, err := ListNotebooks()
	if nil != err {
		return
	}

	for _, notebook := range notebooks {
		boxID := notebook.ID
		historyDir := filepath.Join(util.DataDir, boxID, ".siyuan", "history")
		if !gulu.File.IsDir(historyDir) {
			continue
		}

		if err = os.RemoveAll(historyDir); nil != err {
			logging.LogErrorf("remove notebook history dir [%s] failed: %s", historyDir, err)
			return
		}
		logging.LogInfof("removed notebook history dir [%s]", historyDir)
	}

	historyDir = filepath.Join(util.DataDir, ".siyuan", "history")
	if gulu.File.IsDir(historyDir) {
		if err = os.RemoveAll(historyDir); nil != err {
			logging.LogErrorf("remove data history dir [%s] failed: %s", historyDir, err)
			return
		}
		logging.LogInfof("removed data history dir [%s]", historyDir)
	}
	historyDir = filepath.Join(util.DataDir, "assets", ".siyuan", "history")
	if gulu.File.IsDir(historyDir) {
		if err = os.RemoveAll(historyDir); nil != err {
			logging.LogErrorf("remove assets history dir [%s] failed: %s", historyDir, err)
			return
		}
		logging.LogInfof("removed assets history dir [%s]", historyDir)
	}
	return
}

func GetDocHistoryContent(historyPath string) (content string, err error) {
	if !gulu.File.IsExist(historyPath) {
		return
	}

	data, err := filelock.NoLockFileRead(historyPath)
	if nil != err {
		logging.LogErrorf("read file [%s] failed: %s", historyPath, err)
		return
	}
	luteEngine := NewLute()
	historyTree, err := parse.ParseJSONWithoutFix(data, luteEngine.ParseOptions)
	if nil != err {
		logging.LogErrorf("parse tree from file [%s] failed, remove it", historyPath)
		os.RemoveAll(historyPath)
		return
	}
	content = renderBlockMarkdown(historyTree.Root)
	return
}

func RollbackDocHistory(boxID, historyPath string) (err error) {
	if !gulu.File.IsExist(historyPath) {
		return
	}

	WaitForWritingFiles()
	writingDataLock.Lock()

	srcPath := historyPath
	var destPath string
	baseName := filepath.Base(historyPath)
	id := strings.TrimSuffix(baseName, ".sy")

	filelock.ReleaseFileLocks(filepath.Join(util.DataDir, boxID))
	workingDoc := treenode.GetBlockTree(id)
	if nil != workingDoc {
		if err = os.RemoveAll(filepath.Join(util.DataDir, boxID, workingDoc.Path)); nil != err {
			writingDataLock.Unlock()
			return
		}
	}

	destPath, err = getRollbackDockPath(boxID, historyPath)
	if nil != err {
		writingDataLock.Unlock()
		return
	}

	if err = gulu.File.Copy(srcPath, destPath); nil != err {
		writingDataLock.Unlock()
		return
	}
	writingDataLock.Unlock()

	RefreshFileTree()
	IncSync()
	return nil
}

func getRollbackDockPath(boxID, historyPath string) (destPath string, err error) {
	baseName := filepath.Base(historyPath)
	parentID := strings.TrimSuffix(filepath.Base(filepath.Dir(historyPath)), ".sy")
	parentWorkingDoc := treenode.GetBlockTree(parentID)
	if nil != parentWorkingDoc {
		// 父路径如果是文档，则恢复到父路径下
		parentDir := strings.TrimSuffix(parentWorkingDoc.Path, ".sy")
		parentDir = filepath.Join(util.DataDir, boxID, parentDir)
		if err = os.MkdirAll(parentDir, 0755); nil != err {
			return
		}
		destPath = filepath.Join(parentDir, baseName)
	} else {
		// 父路径如果不是文档，则恢复到笔记本根路径下
		destPath = filepath.Join(util.DataDir, boxID, baseName)
	}
	return
}

func RollbackAssetsHistory(historyPath string) (err error) {
	historyPath = filepath.Join(util.WorkspaceDir, historyPath)
	if !gulu.File.IsExist(historyPath) {
		return
	}

	from := historyPath
	to := filepath.Join(util.DataDir, "assets", filepath.Base(historyPath))

	if err = gulu.File.Copy(from, to); nil != err {
		logging.LogErrorf("copy file [%s] to [%s] failed: %s", from, to, err)
		return
	}
	IncSync()
	return nil
}

func RollbackNotebookHistory(historyPath string) (err error) {
	if !gulu.File.IsExist(historyPath) {
		return
	}

	from := historyPath
	to := filepath.Join(util.DataDir, filepath.Base(historyPath))

	if err = gulu.File.Copy(from, to); nil != err {
		logging.LogErrorf("copy file [%s] to [%s] failed: %s", from, to, err)
		return
	}

	RefreshFileTree()
	IncSync()
	return nil
}

type History struct {
	HCreated string         `json:"hCreated"`
	Items    []*HistoryItem `json:"items"`
}

type HistoryItem struct {
	Title string `json:"title"`
	Path  string `json:"path"`
}

const maxHistory = 32

func GetDocHistory(boxID string) (ret []*History, err error) {
	ret = []*History{}

	historyDir := util.HistoryDir
	if !gulu.File.IsDir(historyDir) {
		return
	}

	historyBoxDirs, err := filepath.Glob(historyDir + "/*/" + boxID)
	if nil != err {
		logging.LogErrorf("read dir [%s] failed: %s", historyDir, err)
		return
	}
	sort.Slice(historyBoxDirs, func(i, j int) bool {
		return historyBoxDirs[i] > historyBoxDirs[j]
	})

	luteEngine := NewLute()
	count := 0
	for _, historyBoxDir := range historyBoxDirs {
		var docs []*HistoryItem
		itemCount := 0
		filepath.Walk(historyBoxDir, func(path string, info fs.FileInfo, err error) error {
			if info.IsDir() {
				return nil
			}

			if !strings.HasSuffix(info.Name(), ".sy") {
				return nil
			}

			data, err := filelock.NoLockFileRead(path)
			if nil != err {
				logging.LogErrorf("read file [%s] failed: %s", path, err)
				return nil
			}
			historyTree, err := parse.ParseJSONWithoutFix(data, luteEngine.ParseOptions)
			if nil != err {
				logging.LogErrorf("parse tree from file [%s] failed, remove it", path)
				os.RemoveAll(path)
				return nil
			}
			historyName := historyTree.Root.IALAttr("title")
			if "" == historyName {
				historyName = info.Name()
			}

			docs = append(docs, &HistoryItem{
				Title: historyTree.Root.IALAttr("title"),
				Path:  path,
			})
			itemCount++
			if maxHistory < itemCount {
				return io.EOF
			}
			return nil
		})

		if 1 > len(docs) {
			continue
		}

		timeDir := filepath.Base(filepath.Dir(historyBoxDir))
		t := timeDir[:strings.LastIndex(timeDir, "-")]
		if ti, parseErr := time.Parse("2006-01-02-150405", t); nil == parseErr {
			t = ti.Format("2006-01-02 15:04:05")
		}

		ret = append(ret, &History{
			HCreated: t,
			Items:    docs,
		})

		count++
		if maxHistory <= count {
			break
		}
	}

	sort.Slice(ret, func(i, j int) bool {
		return ret[i].HCreated > ret[j].HCreated
	})
	return
}

func GetNotebookHistory() (ret []*History, err error) {
	ret = []*History{}

	historyDir := util.HistoryDir
	if !gulu.File.IsDir(historyDir) {
		return
	}

	historyNotebookConfs, err := filepath.Glob(historyDir + "/*-delete/*/.siyuan/conf.json")
	if nil != err {
		logging.LogErrorf("read dir [%s] failed: %s", historyDir, err)
		return
	}
	sort.Slice(historyNotebookConfs, func(i, j int) bool {
		iTimeDir := filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(historyNotebookConfs[i]))))
		jTimeDir := filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(historyNotebookConfs[j]))))
		return iTimeDir > jTimeDir
	})

	historyCount := 0
	for _, historyNotebookConf := range historyNotebookConfs {
		timeDir := filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(historyNotebookConf))))
		t := timeDir[:strings.LastIndex(timeDir, "-")]
		if ti, parseErr := time.Parse("2006-01-02-150405", t); nil == parseErr {
			t = ti.Format("2006-01-02 15:04:05")
		}

		var c conf.BoxConf
		data, readErr := os.ReadFile(historyNotebookConf)
		if nil != readErr {
			logging.LogErrorf("read notebook conf [%s] failed: %s", historyNotebookConf, readErr)
			continue
		}
		if err = json.Unmarshal(data, &c); nil != err {
			logging.LogErrorf("parse notebook conf [%s] failed: %s", historyNotebookConf, err)
			continue
		}

		ret = append(ret, &History{
			HCreated: t,
			Items: []*HistoryItem{
				{
					Title: c.Name,
					Path:  filepath.Dir(filepath.Dir(historyNotebookConf)),
				},
			},
		})

		historyCount++
		if maxHistory <= historyCount {
			break
		}
	}

	sort.Slice(ret, func(i, j int) bool {
		return ret[i].HCreated > ret[j].HCreated
	})
	return
}

func GetAssetsHistory() (ret []*History, err error) {
	ret = []*History{}

	historyDir := util.HistoryDir
	if !gulu.File.IsDir(historyDir) {
		return
	}

	historyAssetsDirs, err := filepath.Glob(historyDir + "/*/assets")
	if nil != err {
		logging.LogErrorf("read dir [%s] failed: %s", historyDir, err)
		return
	}
	sort.Slice(historyAssetsDirs, func(i, j int) bool {
		return historyAssetsDirs[i] > historyAssetsDirs[j]
	})

	historyCount := 0
	for _, historyAssetsDir := range historyAssetsDirs {
		var assets []*HistoryItem
		itemCount := 0
		filepath.Walk(historyAssetsDir, func(path string, info fs.FileInfo, err error) error {
			if isSkipFile(info.Name()) {
				if info.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
			if info.IsDir() {
				return nil
			}

			assets = append(assets, &HistoryItem{
				Title: info.Name(),
				Path:  filepath.ToSlash(strings.TrimPrefix(path, util.WorkspaceDir)),
			})
			itemCount++
			if maxHistory < itemCount {
				return io.EOF
			}
			return nil
		})

		if 1 > len(assets) {
			continue
		}

		timeDir := filepath.Base(filepath.Dir(historyAssetsDir))
		t := timeDir[:strings.LastIndex(timeDir, "-")]
		if ti, parseErr := time.Parse("2006-01-02-150405", t); nil == parseErr {
			t = ti.Format("2006-01-02 15:04:05")
		}

		ret = append(ret, &History{
			HCreated: t,
			Items:    assets,
		})

		historyCount++
		if maxHistory <= historyCount {
			break
		}
	}

	sort.Slice(ret, func(i, j int) bool {
		return ret[i].HCreated > ret[j].HCreated
	})
	return
}

func (box *Box) generateDocHistory0() {
	files := box.recentModifiedDocs()
	if 1 > len(files) {
		return
	}

	historyDir, err := GetHistoryDir(HistoryOpUpdate)
	if nil != err {
		logging.LogErrorf("get history dir failed: %s", err)
		return
	}

	for _, file := range files {
		historyPath := filepath.Join(historyDir, box.ID, strings.TrimPrefix(file, filepath.Join(util.DataDir, box.ID)))
		if err = os.MkdirAll(filepath.Dir(historyPath), 0755); nil != err {
			logging.LogErrorf("generate history failed: %s", err)
			return
		}

		var data []byte
		if data, err = filelock.NoLockFileRead(file); err != nil {
			logging.LogErrorf("generate history failed: %s", err)
			return
		}

		if err = gulu.File.WriteFileSafer(historyPath, data, 0644); err != nil {
			logging.LogErrorf("generate history failed: %s", err)
			return
		}
	}

	luteEngine := NewLute()
	indexHistoryDir(filepath.Base(historyDir), luteEngine)
	return
}

func clearOutdatedHistoryDir(historyDir string) {
	if !gulu.File.IsExist(historyDir) {
		return
	}

	dirs, err := os.ReadDir(historyDir)
	if nil != err {
		logging.LogErrorf("clear history [%s] failed: %s", historyDir, err)
		return
	}

	now := time.Now()
	var removes []string
	for _, dir := range dirs {
		dirInfo, err := dir.Info()
		if nil != err {
			logging.LogErrorf("read history dir [%s] failed: %s", dir.Name(), err)
			continue
		}
		if Conf.Editor.HistoryRetentionDays < int(now.Sub(dirInfo.ModTime()).Hours()/24) {
			removes = append(removes, filepath.Join(historyDir, dir.Name()))
		}
	}
	for _, dir := range removes {
		if err = os.RemoveAll(dir); nil != err {
			logging.LogErrorf("remove history dir [%s] failed: %s", dir, err)
			continue
		}
		//logging.LogInfof("auto removed history dir [%s]", dir)

		// 清理历史库

		tx, txErr := sql.BeginHistoryTx()
		if nil != txErr {
			logging.LogErrorf("begin history tx failed: %s", txErr)
			return
		}

		p := strings.TrimPrefix(dir, util.HistoryDir)
		p = filepath.ToSlash(p[1:])
		if txErr = sql.DeleteHistoriesByPathPrefix(tx, dir); nil != txErr {
			sql.RollbackTx(tx)
			logging.LogErrorf("delete history [%s] failed: %s", dir, txErr)
			return
		}
		if txErr = sql.CommitTx(tx); nil != txErr {
			logging.LogErrorf("commit history tx failed: %s", txErr)
			return
		}
	}
}

var boxLatestHistoryTime = map[string]time.Time{}

func (box *Box) recentModifiedDocs() (ret []string) {
	latestHistoryTime := boxLatestHistoryTime[box.ID]
	filepath.Walk(filepath.Join(util.DataDir, box.ID), func(path string, info fs.FileInfo, err error) error {
		if nil == info {
			return nil
		}
		if isSkipFile(info.Name()) {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		if info.IsDir() {
			return nil
		}

		if info.ModTime().After(latestHistoryTime) {
			ret = append(ret, filepath.Join(path))
		}
		return nil
	})
	box.UpdateHistoryGenerated()
	return
}

const (
	HistoryOpClean  = "clean"
	HistoryOpUpdate = "update"
	HistoryOpDelete = "delete"
	HistoryOpFormat = "format"
)

func GetHistoryDir(suffix string) (ret string, err error) {
	ret = filepath.Join(util.HistoryDir, time.Now().Format("2006-01-02-150405")+"-"+suffix)
	if err = os.MkdirAll(ret, 0755); nil != err {
		logging.LogErrorf("make history dir failed: %s", err)
		return
	}
	return
}

func indexHistory() {
	historyDirs, err := os.ReadDir(util.HistoryDir)
	if nil != err {
		logging.LogErrorf("read history dir [%s] failed: %s", util.HistoryDir, err)
		return
	}

	lutEngine := NewLute()
	for _, historyDir := range historyDirs {
		if !historyDir.IsDir() {
			continue
		}

		name := historyDir.Name()
		err = indexHistoryDir(name, lutEngine)
		if nil != err {
			return
		}
	}
}

var validOps = []string{HistoryOpClean, HistoryOpUpdate, HistoryOpDelete, HistoryOpFormat}

func indexHistoryDir(name string, luteEngine *lute.Lute) (err error) {
	op := name[strings.LastIndex(name, "-")+1:]
	if !gulu.Str.Contains(op, validOps) {
		logging.LogWarnf("invalid history op [%s]", op)
		return
	}
	t := name[:strings.LastIndex(name, "-")]
	tt, parseErr := time.Parse("2006-01-02-150405", t)
	if nil != parseErr {
		logging.LogWarnf("parse history dir time [%s] failed: %s", t, parseErr)
		return
	}
	created := fmt.Sprintf("%d", tt.Unix())

	entryPath := filepath.Join(util.HistoryDir, name)
	var docs, assets []string
	filepath.Walk(entryPath, func(path string, info os.FileInfo, err error) error {
		if strings.HasSuffix(info.Name(), ".sy") {
			docs = append(docs, path)
		} else if strings.Contains(path, "assets/") {
			assets = append(assets, path)
		}
		return nil
	})

	var histories []*sql.History
	for _, doc := range docs {
		tree, loadErr := loadTree(doc, luteEngine)
		if nil != loadErr {
			logging.LogErrorf("load tree [%s] failed: %s", doc, loadErr)
			continue
		}

		title := tree.Root.IALAttr("title")
		content := tree.Root.Content()
		p := strings.TrimPrefix(doc, util.HistoryDir)
		p = filepath.ToSlash(p[1:])
		histories = append(histories, &sql.History{
			Type:    0,
			Op:      op,
			Title:   title,
			Content: content,
			Path:    p,
			Created: created,
		})
	}

	for _, asset := range assets {
		p := strings.TrimPrefix(asset, util.HistoryDir)
		p = filepath.ToSlash(p[1:])
		histories = append(histories, &sql.History{
			Type:    1,
			Op:      op,
			Title:   filepath.Base(asset),
			Path:    p,
			Created: created,
		})
	}

	tx, txErr := sql.BeginHistoryTx()
	if nil != txErr {
		msg := fmt.Sprintf("begin transaction failed: %s", txErr)
		err = errors.New(msg)
		return
	}
	if err = sql.InsertHistories(tx, histories); nil != err {
		msg := fmt.Sprintf("insert histories failed: %s", err)
		err = errors.New(msg)
		sql.RollbackTx(tx)
		return
	}
	if err = sql.CommitTx(tx); nil != err {
		msg := fmt.Sprintf("commit transaction failed: %s", err)
		err = errors.New(msg)
		return
	}
	return
}

func fullTextSearchHistory(query string, page int) (ret []*History, matchedBlockCount, matchedRootCount int) {
	query = gulu.Str.RemoveInvisible(query)
	query = stringQuery(query)

	table := "histories_fts_case_insensitive"
	projections := "type, op, title, content, path"
	stmt := "SELECT " + projections + " FROM " + table + " WHERE " + table + " MATCH '{title content}:(" + query + ")'"
	stmt += " ORDER BY created DESC LIMIT " + strconv.Itoa(page)
	sqlHistories := sql.SelectHistoriesRawStmt(stmt)
	if 1 > len(sqlHistories) {
		ret = []*History{}
		return
	}

	var items []*HistoryItem
	var tmpTime int64
	for _, sqlHistory := range sqlHistories {
		unixSec, _ := strconv.ParseInt(sqlHistory.Created, 10, 64)
		if 0 == tmpTime {
			tmpTime = unixSec
		}
		if tmpTime == unixSec {
			items = append(items, &HistoryItem{
				Title: sqlHistory.Title,
				Path:  filepath.Join(util.HistoryDir, sqlHistory.Path),
			})
		} else {
			ret = append(ret, &History{
				HCreated: time.Unix(unixSec, 0).Format("2006-01-02 15:04:05"),
				Items:    items,
			})
			items = []*HistoryItem{}
		}
	}
	return
}
